/* eslint-disable @typescript-eslint/ban-types */
/* eslint-disable @typescript-eslint/no-unsafe-return, @typescript-eslint/explicit-module-boundary-types */
/* eslint-disable @typescript-eslint/restrict-plus-operands */

declare const Zotero: any

import { RegularItem as Item } from '../../gen/typings/serialized-item'
import { Cache } from '../../typings/cache'
import type { Translators } from '../../typings/translators'
import * as DateParser from '../../content/dateparser'

import { Translation } from '../lib/translator'

import * as postscript from '../lib/postscript'

import { replace_command_spacers } from './unicode_translator'
import { datefield } from './datefield'
import * as ExtraFields from '../../gen/items/extra-fields.json'
import { label as propertyLabel } from '../../gen/items/items'
import type { Fields as ParsedExtraFields } from '../../content/extra'
import { zoteroCreator as ExtraZoteroCreator } from '../../content/extra'
import { log } from '../../content/logger'
import { babelLanguage, titleCase } from '../../content/text'
import BabelTag from '../../gen/babel/tag.json'

import { arXiv } from '../../content/arXiv'

import { stringCompare } from '../lib/string-compare'
import * as CSL from 'citeproc'

import { toWordsOrdinal, toOrdinal } from 'number-to-words'

/*
 * h1 class: Entry
 *
 * The Bib(La)TeX entries are generated by the `Entry` class. Before being comitted to the cache, you can add
 * postscript code that can manipulated the `has` or the `entrytype`
 *
 * @param {String} @entrytype entrytype
 * @param {Object} @item the current Zotero item being converted
 */

const fieldOrder = [
  'type',
  'entrysubtype',
  'ids',
  'title',
  'shorttitle',
  'booktitle',
  'author',
  'authortype',
  'editor',
  'editortype',
  'editora',
  'editoratype',
  'editorb',
  'editorbtype',
  'translator',
  'translatortype',
  'holder',
  'holdertype',
  'options',
  'date',
  'origdate',
  'year',
  'month',
  'journal',
  'journaltitle',
  'shortjournal',
  'series',
  'edition',
  'volume',
  'number',
  'eprint',
  'eprinttype',
  'eprintclass',
  'primaryclass',
  'pages',
  'publisher',
  'address',
  'institution',
  'location',
  'issn',
  'doi',
  'url',
  'urldate',

  '-keywords',
  '-annotation',
  '-note',
  '-groups',
  '-timestamp',
  '-files',
  '-file',
].reduce((acc, field, idx) => {
  if (field[0] === '-') {
    acc[field.substring(1)] = -(idx + 1)
  }
  else {
    acc[field] = idx + 1
  }
  return acc
}, {})

function property_sort(a: [string, string], b: [string, string]): number {
  return stringCompare(a[0], b[0])
}

const enc_creators_marker = {
  initials: /[\u2063\u0097]/g, // invisible separator, end of guarded area
  relax: '\u200C', // zero-width non-joiner
}
const isBibString = /^[a-z][-a-z0-9_]*$/i

export type Config = {
  fieldEncoding: Record<string, 'raw' | 'url' | 'verbatim' | 'creators' | 'literal' | 'latex' | 'tags' | 'attachments' | 'date'>
  caseConversion: Record<string, boolean>
  typeMap: {
    csl: Record<string, string | { type: string, subtype?: string }>
    zotero: Record<string, string | { type: string, subtype?: string }>
  }
}

/*
 * The fields are objects with the following keys:
 *   * name: name of the Bib(La)TeX field
 *   * value: the value of the field
 *   * bibtex: the LaTeX-encoded value of the field
 *   * enc: the encoding to use for the field
 */
export class Entry {
  public has: { [key: string]: any } = {}
  public item: Item
  public entrytype: string
  public entrytype_source: string
  public useprefix: boolean
  public language: string
  public english: boolean
  public date: DateParser.ParsedDate | { type: 'none' }

  public config: Config

  private inPostscript = false
  private quality_report: string[] = []
  private extraFields: ParsedExtraFields

  private re: {
    punctuationAtEnd: any
    startsWithLowercase: any
    hasLowercaseWord: any
    whitespace: any
    nonwordish: any
    leadingUppercase: any
    initials: any
    longInitials: any
    allCaps: any
  }

  protected addCreators() {} // eslint-disable-line @typescript-eslint/no-empty-function

  public static installPostscript(translation: Translation): void {
    try {
      if (translation.preferences.postscript.trim()) {
        Entry.prototype.postscript = postscript.postscript(
          'tex',
          translation.preferences.postscript,
          'this.inPostscript' // workaround for https://github.com/Juris-M/zotero/issues/65
        )
      }
      else {
        Entry.prototype.postscript = postscript.noop
      }
    }
    catch (err) {
      Entry.prototype.postscript = postscript.noop
      log.error('failed to install postscript', err, '\n', translation.preferences.postscript)
    }
  }

  private metadata: Cache.ExportedItemMetadata = { DeclarePrefChars: '', noopsort: false, packages: [] }
  private packages: Record<string, boolean> = {}
  private juniorcomma: boolean
  private translation: Translation

  public lint(_explanation: Record<string, string>): string[] {
    return []
  }

  constructor(item, config: Config, translation: Translation) {
    if (!this.re) {
      Entry.prototype.re = {
        // private nonLetters: new Zotero.Utilities.XRegExp('[^\\p{Letter}]', 'g')
        punctuationAtEnd: new Zotero.Utilities.XRegExp('[\\p{Punctuation}]$'),
        startsWithLowercase: new Zotero.Utilities.XRegExp('^[\\p{Ll}]'),
        hasLowercaseWord: new Zotero.Utilities.XRegExp('\\s[\\p{Ll}]'),
        whitespace: new Zotero.Utilities.XRegExp('[\\p{Zs}]+'),
        nonwordish: new Zotero.Utilities.XRegExp('[^\\p{L}\\p{N}]', 'g'),
        leadingUppercase: new Zotero.Utilities.XRegExp('^(\\p{Lu})(\\p{Lu}*)(\\p{Ll}.*)'),
        initials: new Zotero.Utilities.XRegExp('^(\\p{L}+\\.\\s*)+$'),
        longInitials: new Zotero.Utilities.XRegExp('\\p{L}{2}\\.'),
        allCaps: new Zotero.Utilities.XRegExp('^\\p{Lu}{2,}$'),
      }
    }

    this.translation = translation
    this.item = item
    this.config = config
    this.date = item.date ? DateParser.parse(item.date) : { type: 'none' }

    if (!item.language) {
      this.english = true
    }
    else {
      this.language = babelLanguage(item.language)
      this.english = BabelTag[this.language] === 'en'
    }

    this.extraFields = JSON.parse(JSON.stringify(item.extraFields))

    // should be const entrytype: string | { type: string, subtype?: string }
    // https://github.com/Microsoft/TypeScript/issues/10422
    let entrytype: any

    // workaround for preprints, https://forums.zotero.org/discussion/comment/385524#Comment_385524
    const pseudoPrePrint = this.translation.BetterBibTeX && item.itemType === 'report' && item.extraFields.kv.type?.toLowerCase() === 'article'

    // preserve for thesis type etc
    let csl_type = item.extraFields.kv.type
    if (!pseudoPrePrint && config.typeMap.csl[csl_type]) {
      delete item.extraFields.kv.type
    }
    else {
      csl_type = null
    }

    if (item.extraFields.tex.entrytype) {
      entrytype = item.extraFields.tex.entrytype.value
      this.entrytype_source = `tex.${entrytype}`

      delete item.extraFields.tex.referencetype
    }
    else if (item.extraFields.tex.referencetype) { // phase out reference
      entrytype = item.extraFields.tex.referencetype.value
      this.entrytype_source = `tex.${entrytype}`

      item.extraFields.tex.entrytype = item.extraFields.tex.referencetype
      delete item.extraFields.tex.referencetype
    }
    else if (csl_type) {
      entrytype = config.typeMap.csl[csl_type]
      this.entrytype_source = `csl.${csl_type}`
    }
    else if (pseudoPrePrint) {
      entrytype = 'misc'
      delete item.extraFields.kv.type
      this.entrytype_source = `zotero.${item.itemType}`
    }
    else {
      entrytype = config.typeMap.zotero[item.itemType] || 'misc'
      this.entrytype_source = `zotero.${item.itemType}`
    }

    if (typeof entrytype === 'string') {
      this.entrytype = entrytype
    }
    else {
      this.add({ name: 'entrysubtype', value: entrytype.subtype })
      this.entrytype = entrytype.type
    }

    // TODO: maybe just use item.extraFields.var || item.var instead of deleting them here
    for (const [name, value] of Object.entries(item.extraFields.kv)) {
      const ef = ExtraFields[name]
      if (ef?.zotero) {
        if (!item[name] || ef.type === 'date') {
          item[name] = value
        }
        /*
        else {
          log.debug('extra fields: skipping', {name, value})
        }
        */
        delete item.extraFields.kv[name]
      }
    }

    for (const [name, value] of Object.entries(item.extraFields.creator)) {
      if (ExtraFields[name].zotero) {
        for (const creator of (value as string[])) {
          item.creators.push({...ExtraZoteroCreator(creator, name), source: creator})
        }
        delete item.extraFields.creator[name]
      }
    }

    if (this.translation.preferences.jabrefFormat) {
      if (this.translation.preferences.testing) {
        this.add({name: 'timestamp', value: '2015-02-24 12:14:36 +0100'})
      }
      else {
        this.add({name: 'timestamp', value: item.dateModified || item.dateAdded})
      }
    }

    const eprinttype = this.translation.BetterBibTeX ? 'archiveprefix' : 'eprinttype'
    const eprintclass = this.translation.BetterBibTeX ? 'primaryclass' : 'eprintclass'

    item.arXiv = new arXiv

    if (item.itemType === 'preprint' && item.publisher) {
      if (item.publisher?.match(/arxiv/i)) {
        item.arXiv.parse(item.number, 'preprint')
      }
      else {
        this.add({ name: 'eprint', value: item.number })
        this.add({ name: eprinttype, value: item.publisher })
        this.add({ name: eprintclass, value: item.section })
        item.arXiv = null
      }
    }
    else if (item.itemType === 'dataset' && item.archive) {
      this.add({ name: 'eprint', value: item.archiveLocation })
      this.add({ name: eprinttype, value: item.archive })
      this.add({ name: eprintclass, value: item.section })
      item.arXiv = null
    }
    else if (this.extractEprint()) {
      item.arXiv = null
    }

    if (item.arXiv) {
      if (item.arXiv.parse(item.publicationTitle, 'publicationTitle')) {
        if (this.translation.BetterBibLaTeX) delete item.publicationTitle
      }

      item.arXiv.parse(item.extraFields.tex.arxiv?.value, 'extra')

      if (!item.arXiv.id) item.arXiv = null
    }

    if (item.arXiv) {
      delete item.extraFields.tex.arxiv

      // section can be set from extra
      item.arXiv.category = item.arXiv.category || item.section

      this.add({ name: 'eprint', value: item.arXiv.id })
      this.add({ name: eprinttype, value: 'arxiv'})
      this.add({ name: eprintclass, value: item.arXiv.category })
    }

    // aliases
    // if (this.has.eprintclass) this.add({ name: 'primaryclass', value: this.has.eprintclass.value })
    // if (this.has.eprinttype) this.add({ name: 'archiveprefix', value: this.has.eprinttype.value })
  }

  private extractEprint(): boolean {
    if (!this.translation.preferences.biblatexExtractEprint || !this.item.url) return false

    let m
    if (m = this.item.url.match(/^https?:\/\/www.jstor.org\/stable\/([\S]+)$/i)) {
      this.add({ name: 'eprinttype', value: 'jstor'})
      this.add({ name: 'eprint', value: m[1].replace(/\?.*/, '') })
    }
    else if (m = this.item.url.match(/^https?:\/\/books.google.com\/books?id=([\S]+)$/i)) {
      this.add({ name: 'eprinttype', value: 'googlebooks'})
      this.add({ name: 'eprint', value: m[1] })
    }
    else if (m = this.item.url.match(/^https?:\/\/www.ncbi.nlm.nih.gov\/pubmed\/([\S]+)$/i)) {
      this.add({ name: 'eprinttype', value: 'pubmed'})
      this.add({ name: 'eprint', value: m[1] })
    }
    else {
      return false
    }

    // delete this.item.url
    // this.remove('url')
    return true
  }

  private valueish(value) {
    switch (typeof value) {
      case 'number': return `${value}`
      case 'string': return Zotero.Utilities.XRegExp.replace(value, this.re.nonwordish, '', 'all').toLowerCase()
      default: return ''
    }
  }

  /** normalize dashes, mainly for use in `pages` */
  public normalizeDashes(str): string {
    str = (str || '').trim()

    if (this.item.raw) return str

    return str
      .replace(/\u2053/g, '~')
      .replace(/[\u2014\u2015]/g, '---') // em-dash
      .replace(/[\u2012\u2013]/g, '--') // en-dash
      .split(/(,\s*)/).map(range => {
        if (range.match(/^,\s+/)) return ', '
        if (range === ',') return range

        return range
          .replace(/^([0-9]+)\s*(-+)\s*([0-9]+)\s*$/g, '$1$2$3') // treat space-hyphens-space like a range when it's between numbers
          .replace(/^([0-9]+)-([0-9]+)$/g, '$1--$2') // single dash is probably a range, which should be an n-dash
          .replace(/^([0-9]+)-{4,}([0-9]+)$/g, '$1---$2') // > 4 dashes can't be right. Settle for em-dash
      }).join('')
  }

  /*
   * Add a field to the entry field set
   *
   * @param {field} field to add. 'name' must be set, and either 'value' or 'bibtex'. If you set 'bibtex', BBT will trust
   *   you and just use that as-is. If you set 'value', BBT will escape the value according the encoder passed in 'enc'; no
   *   'enc' means 'enc_latex'. If you pass both 'bibtex' and 'latex', 'bibtex' takes precedence (and 'value' will be
   *   ignored)
   */
  public add(field: Translators.BibTeX.Field): string {
    if (this.translation.preferences.testing && !this.inPostscript && field.name !== field.name.toLowerCase()) throw new Error(`Do not add mixed-case field ${field.name}`)

    if (!field.value && !field.bibtex && this.inPostscript) {
      delete this.has[field.name]
      return null
    }

    if (this.translation.skipField?.exec(`${this.translation.BetterBibTeX ? 'bibtex' : 'biblatex'}.${this.entrytype}.${field.name}`)) return null

    field.enc = field.enc || this.config.fieldEncoding[field.name] || 'latex'

    if (field.enc === 'date') {
      if (!field.value) return null

      if (field.value === 'today') {
        return this.add({
          ...field,
          value: '<pre>\\today</pre>',
          enc: 'verbatim',
        })
      }

      // bare year
      // if (this.translation.BetterBibLaTeX && (typeof field.value === 'number' || (typeof field.value === 'string' && field.value.match(/^[0-9]+$/)))) return this.add({...field, bibtex: `${field.value}`, enc: 'latex'})

      if (this.translation.BetterBibLaTeX && this.translation.preferences.biblatexExtendedDateFormat && DateParser.isEDTF(field.value as string, true)) {
        return this.add({
          ...field,
          value: (field.value as string).replace(/[.][0-9]+[a-z]+$/i, ''),
          enc: 'verbatim',
        })
      }

      const date = DateParser.parse(field.value as string)

      this.add(datefield(date, field, this.translation))

      if (date.orig) {
        this.add(datefield(date.orig, {
          ...field,
          name: (field.orig && field.orig.inherit) ? `orig${field.name}` : (field.orig && field.orig.name),
          verbatim: (field.orig && field.orig.inherit && field.verbatim) ? `orig${field.verbatim}` : (field.orig && field.orig.verbatim),
        }, this.translation))
      }

      return field.name
    }

    if (field.fallback && field.replace) throw new Error('pick fallback or replace, buddy')
    if (field.fallback && this.has[field.name]) {
      log.error('add: fallback already filled for', field.name)
      return null
    }

    // legacy field addition
    if (!field.name) {
      log.error('add: empty legacy object', field.name)
      return null
    }

    if (!field.bibtex) {
      if ((typeof field.value !== 'number') && !field.value) return null
      if ((typeof field.value === 'string') && (field.value.trim() === '')) return null
      if (Array.isArray(field.value) && (field.value.length === 0)) return null
    }

    if (this.has[field.name]) {
      if (!Array.isArray(field.value) && this.has[field.name].value === field.value && this.has[field.name].enc === field.enc) return null

      if (!this.inPostscript && !field.replace) {
        const value = field.bibtex ? 'bibtex' : 'value'
        throw new Error(`duplicate field '${field.name}' for ${this.item.citationKey}: old: ${this.has[field.name][value]}, new: ${field[value]}`)
      }

      if (!field.replace) {
        let v_old = this.has[field.name].value
        let v_new = field.value
        if (typeof v_old === 'string' && typeof v_new === 'string') {
          v_old = v_old.toLowerCase()
          v_new = v_new.toLowerCase()
        }
        if (v_old !== v_new) this.quality_report.push(`duplicate "${field.name}" ("${this.has[field.name].value}") ignored`)
      }

      delete this.has[field.name]
    }

    if (!field.bibtex) {
      let bibstring = ''
      if ((typeof field.value === 'number') || (field.bibtexStrings && (bibstring = this.getBibString(field.value)))) {
        field.bibtex = `${bibstring || field.value}`
      }
      else {
        let value
        switch (field.enc) {
          case 'latex':
            value = this.enc_latex(field, { raw: this.item.raw })
            break

          case 'raw':
            value = this.enc_raw(field)
            break

          case 'url':
            value = this.enc_url(field)
            break

          case 'verbatim':
            value = this.enc_verbatim(field)
            break

          case 'creators':
            value = this.enc_creators(field, this.item.raw)
            break

          case 'literal':
            value = this.enc_literal(field, this.item.raw)
            break

          case 'tags':
            value = this.enc_tags(field)
            break

          case 'attachments':
            value = this.enc_attachments(field)
            break

          default:
            throw new Error(`Unexpected field encoding: ${JSON.stringify(field.enc)}`)
        }

        if (!value) {
          if (field.name !== 'file' && field.name !== 'keywords') log.debug('add: no value after encoding', field)
          return null
        }

        value = value.trim()

        // scrub fields of unwanted {}, but not if it's a raw field or a bare field without spaces
        if (!field.bare || (field.value as string).match(/\s/)) {
          // clean up unnecesary {} when followed by a char that safely terminates the command before
          // value = value.replace(/({})+($|[{}$\/\\.;,])/g, '$2') // don't remove trailing {} https://github.com/retorquere/zotero-better-bibtex/issues/1091
          value = `{${value}}`
        }

        field.bibtex = value
      }
    }

    this.has[field.name] = field

    return field.name
  }

  /*
   * Remove a field from the entry field set
   *
   * @param {name} field to remove.
   * @return {Object} the removed field, if present
   */
  public remove(name) {
    const removed = this.has[name] || {}
    delete this.has[name]
    return removed
  }

  public getBibString(value): string {
    if (!value || typeof value !== 'string') return null

    switch (this.translation.preferences.exportBibTeXStrings) {
      case 'off':
        return null

      case 'detect':
        return isBibString.test(value) && value

      case 'match':
        // the importer uppercases string declarations
        return this.translation.bibtex.strings[value.toUpperCase()] && value

      case 'match+reverse':
        // the importer uppercases string declarations
        value = value.toUpperCase()
        return this.translation.bibtex.strings[value] ? value : this.translation.bibtex.strings_reverse[value]

      default:
        return null
    }
  }

  public hasCreator(type): boolean { return (this.item.creators || []).some(creator => creator.creatorType === type) }

  public override(field: Translators.BibTeX.Field): void {
    const itemtype_name = field.name.split('.')
    let name
    if (itemtype_name.length === 2) {
      if (this.entrytype !== itemtype_name[0]) return
      name = itemtype_name[1]
    }
    else {
      name = field.name
    }

    if ((typeof field.value === 'string') && (field.value.trim() === '')) {
      this.remove(name)
      return
    }

    this.add({ ...field, name, replace: (typeof field.replace !== 'boolean' && typeof field.fallback !== 'boolean') || field.replace })
  }

  public complete(): void {
    if (this.translation.preferences.jabrefFormat >= 4 && this.item.collections?.length) {
      const groups = Array.from(new Set(this.item.collections.map(key => this.translation.collections[key]?.name).filter(name => name))).sort()
      this.add({ name: 'groups', value: groups.join(',') })
    }

    // extra-fields has parsed & removed 'ids' to put it into aliases
    if (this.item.extraFields.aliases.length) {
      this.add({ name: 'ids', value: this.item.extraFields.aliases.filter(alias => alias !== this.item.citationKey).join(','), enc: 'verbatim' })
    }

    if (this.translation.BetterBibLaTeX) this.add({ name: 'pubstate', value: this.item.status })

    for (const [key, value] of Object.entries(this.item.extraFields.kv)) {
      if (key === '_eprint') continue

      const type = ExtraFields[key]?.type || 'string'
      let enc = {name: 'creator', text: 'latex'}[type] || type
      const replace = type === 'date'
      // these are handled just like 'arxiv' and 'lccn', respectively
      if (['PMID', 'PMCID'].includes(key) && typeof value === 'string') {
        this.item.extraFields.tex[key.toLowerCase()] = { value, line: -1 }
        delete this.item.extraFields.kv[key]
        continue
      }

      let name = null

      if (this.translation.BetterBibLaTeX) {
        switch (key) {
          case 'issuingAuthority':
            name = 'institution'
            break

          case 'title':
            name = this.entrytype === 'book' ? 'maintitle' : null
            break

          case 'publicationTitle':
            switch (this.entrytype_source) {
              case 'zotero.film':
              case 'zotero.tvBroadcast':
              case 'zotero.videoRecording':
              case 'csl.motion_picture': // TODO: I really should clean these up
                name = 'booktitle'
                break

              case 'zotero.bookSection':
              case 'csl.chapter':
                name = 'maintitle'
                break

              default:
                name = 'journaltitle'
                break
            }
            break

          case 'original-publisher':
            name = 'origpublisher'
            enc = 'literal'
            break

          case 'original-publisher-place':
            name = 'origlocation'
            enc = 'literal'
            break

          case 'original-title':
            name = 'origtitle'
            break

          case 'original-date':
          case 'originalDate':
            name = 'origdate'
            enc = 'date'
            break

          case 'place':
            name = 'location'
            enc = 'literal'
            break

          case 'pages':
            name = 'pages'
            break

          case 'date':
            name = 'date'
            break

          // https://github.com/retorquere/zotero-better-bibtex/issues/644
          case 'event-place':
            name = 'venue'
            break

          case 'accessed':
            name = 'urldate'
            break

          case 'number':
          case 'volume':
          case 'DOI':
          case 'ISBN':
          case 'ISSN':
            name = key.toLowerCase()
            break
        }
      }

      if (this.translation.BetterBibTeX) {
        switch (key) {
          case 'call-number':
            name = 'lccn'
            break

          case 'DOI':
          case 'ISSN':
            name = key.toLowerCase()
            break

          // https://github.com/retorquere/zotero-better-bibtex/issues/644
          case 'event-place':
            name = 'address'
            break
        }
      }

      if (name) {
        this.override({ name, verbatim: name, orig: { inherit: true }, value, enc, replace, fallback: !replace })
      }
      else {
        log.error('Unmapped extra field', key, '=', value)
      }
    }

    this.add({ name: 'annotation', value: this.item.extra?.replace(/\n+/g, newlines => (newlines.length > 1 ? '\n\n' : ' ')).trim() })

    if (this.translation.options.exportNotes) {
      // if bibtexURL === 'note' is active, the note field will have been filled with an URL. In all other cases, if this is attempting to overwrite the 'note' field, I want the test suite to throw an error
      if (!(this.translation.BetterBibTeX && this.translation.preferences.bibtexURL === 'note')) this.add({ name: 'note', value: this.item.notes?.map((note: { note: string }) => note.note).join('</p><p>'), html: true })
    }

    const bibtexStrings = this.translation.preferences.exportBibTeXStrings.startsWith('match')
    for (const [name, field] of Object.entries(this.item.extraFields.tex)) {
      // psuedo-var, sets the entry type. Repeat application here because this needs to override all else.
      if (name === 'entrytype' || name === 'referencetype') { // phase out reference
        this.entrytype = field.value
        continue
      }

      const mode = ({ raw: { raw: true }, cased: { caseConversion: true } }[field.mode]) || {}

      switch (name) {
        case 'mr':
          this.override({ name: 'mrnumber', value: field.value, ...mode })
          break
        case 'zbl':
          this.override({ name: 'zmnumber', value: field.value, ...mode })
          break
        case 'lccn': case 'pmcid':
          this.override({ name, value: field.value, ...mode })
          break
        case 'pmid':
        case 'arxiv':
        case 'jstor':
        case 'hdl':
          if (this.translation.BetterBibLaTeX) {
            this.override({ name: 'eprinttype', value: name })
            this.override({ name: 'eprint', value: field.value, ...mode })
          }
          else {
            this.override({ name, value: field.value, ...mode })
          }
          break
        case 'googlebooksid':
          if (this.translation.BetterBibLaTeX) {
            this.override({ name: 'eprinttype', value: 'googlebooks' })
            this.override({ name: 'eprint', value: field.value, ...mode })
          }
          else {
            this.override({ name: 'googlebooks', value: field.value, ...mode })
          }
          break
        case 'xref':
          this.override({ name, value: field.value, ...mode })
          break

        default:
          this.override({ name, value: field.value, bibtexStrings, ...mode })
          break
      }
    }

    // sort before postscript so the postscript can affect field order
    const keys = Object.keys(this.has).sort((a, b) => {
      const fa = fieldOrder[a]
      const fb = fieldOrder[b]

      if (fa && fb) return Math.abs(fa) - Math.abs(fb)
      if (fa) return -fa
      if (fb) return fb
      return a.localeCompare(b)
    })
    for (const field of keys) {
      const value = this.has[field]
      delete this.has[field]
      this.has[field] = value
    }

    let allow: postscript.Allow = { cache: true, write: true }
    try {
      allow = this.postscript(this, this.item, this.translation, Zotero, this.extraFields)
    }
    catch (err) {
      if (this.translation.preferences.testing) throw err
      log.error('postscript error:', err)
      allow.cache = false
    }
    this.item.$cacheable = this.item.$cacheable && allow.cache

    if (this.translation.skipField) {
      for (const name of Object.keys(this.has)) {
        const fullname = `${this.translation.BetterBibTeX ? 'bibtex' : 'biblatex'}.${this.entrytype}.${name}`
        if (fullname.match(this.translation.skipField)) this.remove(name)
      }
    }

    if (this.has.url && this.has.doi) {
      switch (this.translation.preferences.DOIandURL) {
        case 'url':
          delete this.has.doi
          break
        case 'doi':
          delete this.has.url
          delete this.has.urldate
          break
      }
    }

    if (!Object.keys(this.has).length) this.add({name: 'type', value: this.entrytype})


    let ref = `@${this.entrytype}{${this.item.citationKey},\n`
    ref += Object.values(this.has).map(field => `  ${field.name} = ${field.bibtex}`).join(',\n') + '\n'
    ref += '}\n'
    ref += this.qualityReport()

    if (allow.write) this.translation.output.body += ref

    this.metadata.DeclarePrefChars = this.unique_chars(this.metadata.DeclarePrefChars)

    this.metadata.packages = Object.keys(this.packages)
    if (this.item.$cacheable) {
      Zotero.BetterBibTeX.Cache.store(
        this.translation.translator.label,
        this.item.itemID,
        this.translation.options,
        this.translation.preferences,
        ref,
        this.metadata
      )
    }

    this.translation.bibtex.postfix.add(this.metadata)
  }

  /*
   * 'Encode' to raw LaTeX value
   *
   * @param {field} field to encode
   * @return {String} unmodified `field.value`
   */
  protected enc_raw(f): string {
    return f.value
  }

  /*
   * Encode to LaTeX url
   *
   * @param {field} field to encode
   * @return {String} field.value encoded as verbatim LaTeX string (minimal escaping). If in Better BibTeX, wraps return value in `\url{string}`
   */
  protected enc_url(f): string {
    if (this.translation.BetterBibTeX && this.translation.preferences.bibtexURL.endsWith('-ish')) {
      return (f.value || '').replace(/([#\\%&{}])/g, '\\$1') // or maybe enc_latex?
    }
    else if (this.translation.BetterBibTeX && this.translation.preferences.bibtexURL === 'note') {
      // https://github.com/retorquere/zotero-better-bibtex/issues/2617
      return `\\url{${this.enc_verbatim(f).replace(/([\\%#])/g, '\\$1')}}`
    }
    else {
      return this.enc_verbatim(f)
    }
  }

  /*
   * Encode to verbatim LaTeX
   *
   * @param {field} field to encode
   * @return {String} field.value encoded as verbatim LaTeX string (minimal escaping).
   */
  protected enc_verbatim(f): string {
    let value: string = f.value || ''
    // if (!this.translation.unicode) value = value.replace(/[^\x20-\x7E]/g, (chr => `\\%${`00${chr.charCodeAt(0).toString(16).slice(-2)}`}`))

    // remove unbalanced braces
    const braces: {c: string, pos: number}[] = []
    for (let i = 0; i < value.length; i++) {
      if (value[i] === '{') {
        braces.unshift({c: value[i], pos: i})
      }
      else if (value[i] === '}') {
        if (braces.length && braces[0].c === '{') {
          braces.shift()
        }
        else {
          braces.unshift({c: '', pos: i})
        }
      }
    }
    for (const b of braces) { // braces is already reversed
      value = value.substring(0, b.pos) + value.substring(b.pos + 1)
    }

    return value
  }

  protected _enc_creators_scrub_name(name: string): string {
    name = name.replace(/uFFFC/g, '') // these should never appear
    name = name.replace(/\u00A0/g, '\uFFFC') // safeguard non-breaking spaces -- the only non-space space-ish allowed in names (see #859)
    name =  Zotero.Utilities.XRegExp.replace(name, this.re.whitespace, ' ', 'all') // all the rest must go
    name = name.replace(/\uFFFC/g, '\u00A0') // restore non-breaking spaces
    return name
  }
  /*
   * Encode creators to author-style field
   *
   * @param {field} field to encode. The 'value' must be an array of Zotero-serialized `creator` objects.
   * @return {String} field.value encoded as author-style value
   */
  protected enc_creators(f, raw: boolean) {
    if (f.value.length === 0) return null

    const encoded = []
    for (const creator of f.value) {
      let name
      if (creator.name || (creator.lastName && (creator.fieldMode === 1))) {
        name = creator.name || creator.lastName
        if (name !== 'others') name = raw ? `{${name}}` : this.enc_latex({value: new String(this._enc_creators_scrub_name(name))}) // eslint-disable-line no-new-wrappers

      }
      else if (raw) {
        name = [creator.lastName || '', creator.firstName || ''].join(', ')

      }
      else if (creator.lastName || creator.firstName) {
        name = {
          family: this._enc_creators_scrub_name(creator.lastName || ''),
          given: this._enc_creators_scrub_name(creator.firstName || ''),
        }

        if (this.translation.preferences.parseParticles) CSL.parseParticles(name)

        if (!this.translation.BetterBibLaTeX || !this.translation.preferences.biblatexExtendedNameFormat) {
          // side effects to set use-prefix/uniorcomma -- make sure addCreators is called *before* adding 'options'
          if (!this.useprefix) this.useprefix = !!name['non-dropping-particle']
          if (!this.juniorcomma) this.juniorcomma = (f.juniorcomma && name['comma-suffix'])
        }

        if (this.translation.BetterBibTeX) {
          name = this._enc_creators_bibtex(name)
        }
        else {
          name = this._enc_creators_biblatex(name)
        }

        name = name.replace(/ and /g, ' {and} ')
        if (this.translation.and.names.repl !== ' {and} ') name = name.replace(this.translation.and.names.re, this.translation.and.names.repl)
      }
      else {
        continue
      }

      encoded.push(name.trim())
    }

    return replace_command_spacers(encoded.join(this.translation.preferences.separatorNames))
  }

  /*
   * Encode text to LaTeX literal list (double-braced)
   *
   * This encoding supports simple HTML markup.
   *
   * @param {field} field to encode.
   * @return {String} field.value encoded as author-style value
   */
  protected enc_literal(f, raw = false) {
    if (!f.value) return null
    return this.enc_latex({...f, value: this.translation.preferences.exportBraceProtection ? new String(f.value) : f.value}, { raw }) // eslint-disable-line no-new-wrappers
  }

  /*
   * Encode text to LaTeX
   *
   * This encoding supports simple HTML markup.
   *
   * @param {field} field to encode.
   * @return {String} field.value encoded as author-style value
   */
  protected enc_latex(f, options: { raw?: boolean, creator?: boolean} = {}) {
    if (typeof f.value === 'number') return f.value
    if (!f.value) return null

    if (Array.isArray(f.value)) {
      if (f.value.length === 0) return null
      return f.value.map(elt => this.enc_latex({...f, bibtex: undefined, value: elt}, options)).join(f.sep || '')
    }

    if (f.raw || options.raw) return f.value

    const caseConversion = this.config.caseConversion[f.name] || f.caseConversion
    const { latex, packages, raw } = this.translation.bibtex.text2latex(f.value, {html: f.html, caseConversion: caseConversion && this.english, creator: options.creator })
    for (const pkg of packages) {
      this.packages[pkg] = true
    }
    let value: String | string = latex

    /*
      biblatex has a langid field it can use to exclude non-English
      titles from any lowercasing a style might request, so no
      additional protection by BBT is necessary. bibtex lacks a
      comparable mechanism, so the only thing BBT can do to tell
      bibtex to back off from non-English titles is to wrap the whole
      thing in braces.
    */
    if (caseConversion && this.translation.BetterBibTeX && !this.english && this.translation.preferences.exportBraceProtection) value = `{${value}}`

    if (f.value instanceof String && !raw) value = new String(`{${value}}`) // eslint-disable-line no-new-wrappers
    return value
  }

  protected enc_tags(f): string {
    const tags = f.value
      .map(tag => (typeof tag === 'string' ? { tag } : tag))
      .filter(tag => (this.translation.preferences.automaticTags || (tag.type !== 1)) && tag.tag !== this.translation.preferences.rawLaTag)
    if (tags.length === 0) return null

    tags.sort((a, b) => stringCompare(a.tag, b.tag))

    // eslint-disable-next-line no-new-wrappers
    return tags.map(tag => tag.tag.includes(',') ? new String(tag.tag) : tag.tag).map(tag => this.enc_latex({ value: tag })).join(',')
  }

  relPath(path) {
    const normalize = p => this.translation.paths.caseSensitive ? p : p.toLowerCase()
    const drive = p => this.translation.preferences.platform === 'win' && p.match(/^[a-z]:\//) ? p.substring(0, 2) : ''

    if (drive(this.translation.export.dir) !== drive(path)) return path

    const from = this.translation.export.dir.split(this.translation.paths.sep)
    const to = path.split(this.translation.paths.sep)

    while (from.length && to.length && normalize(from[0]) === normalize(to[0])) {
      from.shift()
      to.shift()
    }
    // eslint-disable-next-line @typescript-eslint/restrict-plus-operands
    return  `..${this.translation.paths.sep}`.repeat(from.length) + to.join(this.translation.paths.sep)
  }

  protected enc_attachments(f, modify?: (path: string) => string): string {
    if (!f.value || (f.value.length === 0)) return null
    const attachments: {title: string, mimetype: string, path: string}[] = []

    // #1939
    if (modify) this.item.$cacheable = false

    for (const attachment of f.value) {
      const att = {
        title: attachment.title,
        mimetype: attachment.contentType || '',
        path: '',
      }

      if (this.translation.options.exportFileData) {
        att.path = attachment.saveFile ? attachment.defaultPath : ''
      }
      else if (attachment.localPath) {
        att.path = attachment.localPath
      }

      if (!att.path) continue // amazon/googlebooks etc links show up as atachments without a path
      // att.path = att.path.replace(/^storage:/, '')
      att.path = att.path.replace(/(?:\s*[{}]+)+\s*/g, ' ')

      if (this.translation.options.exportFileData && attachment.saveFile) {
        this.translation.output.attachments.push(attachment)
        // attachment.saveFile(attachment.defaultPath, true)
      }

      if (!att.title) att.title = att.path.replace(/.*[\\/]/, '') || 'attachment'

      if (!att.mimetype && (att.path.slice(-4).toLowerCase() === '.pdf')) att.mimetype = 'application/pdf'

      if (this.translation.preferences.relativeFilePaths && this.translation.export.dir) {
        const relative = this.relPath(att.path)
        if (relative !== att.path) {
          this.item.$cacheable = false
          att.path = relative
        }
      }
      if (this.translation.preferences.testing) att.path = att.path.replace(/.*[.]BBTTEST\/(zotero|jurism)\//, '~/BBTTEST/').replace(/\/storage\/[^/]+\//, '/storage/')

      if (modify) att.path = modify(att.path)
      attachments.push(att)
    }

    if (attachments.length === 0) return null

    // sort attachments for stable tests, and to make non-snapshots the default for JabRef to open (#355)
    attachments.sort((a, b) => {
      if ((a.mimetype === 'text/html') && (b.mimetype !== 'text/html')) return 1
      if ((b.mimetype === 'text/html') && (a.mimetype !== 'text/html')) return -1
      return stringCompare(a.path, b.path)
    })

    if (this.translation.preferences.jabrefFormat) return attachments.map(att => [att.title, att.path, att.mimetype].map(part => part.replace(/([\\{}:;])/g, '\\$1')).join(':')).join(';')
    if (attachments.length > 1) return attachments.map(att => att.path.replace(/([\\{}:;])/g, '\\$1')).join(';')
    return this.enc_verbatim({ value: attachments[0].path })
  }

  private _enc_creators_pad_particle(particle: string, relax = false): string {
    // space at end is always OK
    if (particle[particle.length - 1] === ' ') return particle

    if (this.translation.BetterBibLaTeX) {
      if (Zotero.Utilities.XRegExp.test(particle, this.re.punctuationAtEnd)) this.metadata.DeclarePrefChars += particle[particle.length - 1]
      // if BBLT, always add a space if it isn't there
      return `${particle} `
    }

    // otherwise, we're in BBT.

    // If the particle ends in a period, add a space
    if (particle[particle.length - 1] === '.') return `${particle} `

    // if it ends in any other punctuation, it's probably something like d'Medici -- no space
    if (Zotero.Utilities.XRegExp.test(particle, this.re.punctuationAtEnd)) {
      if (relax) return `${particle}${enc_creators_marker.relax} `
      return particle
    }

    // otherwise, add a space
    return `${particle} `
  }

  private detectInitials(name: { given?: string, initials?: string}) {
    if (!name.given) return

    if (Zotero.Utilities.XRegExp.exec(name.given, this.re.allCaps) || (Zotero.Utilities.XRegExp.exec(name.given, this.re.initials) && Zotero.Utilities.XRegExp.exec(name.given, this.re.longInitials))) {
      name.initials = name.given
      return
    }

    if (name.given.includes('.')) return

    let initials = ''
    let given = ''
    let multiChar = ''
    for (const part of name.given.split(/\s+/)) {
      const m = Zotero.Utilities.XRegExp.exec(part, this.re.leadingUppercase)
      if (!m) return { given }
      multiChar = multiChar || m[2]

      // special case for #2419, IJsbrand
      if (m[1] !== 'I' || m[2] !== 'J') m[2] = m[2].toLowerCase()

      initials += `${m[1]}${m[2]}. `
      given += `${m[1]}${m[2]}${m[3]} `
    }
    if (multiChar) {
      name.initials = initials.trim()
      name.given = given.trim()
    }
  }

  private protectInitials(name: { given?: string, initials?: string }) {
    if (!name.given || !name.initials) return

    let initials: string
    if (name.given === name.initials) {
      if (Zotero.Utilities.XRegExp.exec(name.given, this.re.allCaps)) {
        name.given = `<span relax="true">${name.given}</span>`
      }
      else {
        name.given = name.given.split(' ')
          .map((initial: string) => {
            if (Zotero.Utilities.XRegExp.exec(initial, this.re.longInitials)) {
              return `<span relax="true">${initial.replace(/[.]$/, '')}</span>${initial.endsWith('.') ? '.' : ''}`
            }
            else {
              return initial
            }
          })
          .join(' ')
      }
    }
    else if (name.given.startsWith(initials = name.initials.replace(/\.$/, ''))) {
      name.given = `<span relax="true">${initials}</span>${name.given.substr(initials.length)}`
    }
  }

  // eslint-disable-next-line @typescript-eslint/ban-types
  private _enc_creator_part(part: string | String): string | String {
    const { latex, packages } = this.translation.bibtex.text2latex((part as string), { creator: true, commandspacers: true })
    for (const pkg of packages) {
      this.packages[pkg] = true
    }
    return (part instanceof String) ? new String(`{${latex}}`) : latex // eslint-disable-line no-new-wrappers
  }
  private _enc_creators_biblatex(name: {family?: string, given?: string, suffix?: string, initials?: string}): string {
    let family: string | String
    if ((name.family.length > 1) && (name.family[0] === '"') && (name.family[name.family.length - 1] === '"')) {
      family = new String(name.family.slice(1, -1)) // eslint-disable-line no-new-wrappers
    }
    else {
      ({ family } = name)
    }

    // cleanup from old initials detection
    name.given = name.given?.replace(enc_creators_marker.initials, '')
    this.detectInitials(name)

    const extendedNameformat = (
      this.translation.preferences.biblatexExtendedNameFormat
      && (
        name.initials
        ||
        name['dropping-particle']
        ||
        name['non-dropping-particle']
        ||
        name['comma-suffix']
      )
    )

    if (extendedNameformat) {
      const namebuilder: string[] = []
      if (family) namebuilder.push(`family=${this._enc_creator_part(family)}`)
      if (name.given) namebuilder.push(`given=${this._enc_creator_part(name.given)}`)
      if (name.initials) {
        const initials = Zotero.Utilities.XRegExp.exec(name.initials, this.re.allCaps)
          ? name.initials
          : name.initials
            .split(/[\s.]+/)
            .map(initial => initial.length > 1 ? `<span class="nocase">${initial}</span>` : initial)
            .join('')
        namebuilder.push(`given-i=${this._enc_creator_part(initials)}`)
      }
      if (name.suffix) namebuilder.push(`suffix=${this._enc_creator_part(name.suffix)}`)
      if (name['dropping-particle'] || name['non-dropping-particle']) {
        namebuilder.push(`prefix=${this._enc_creator_part(name['dropping-particle'] || name['non-dropping-particle'])}`)
        namebuilder.push(`useprefix=${!!name['non-dropping-particle']}`)
      }
      if (name['comma-suffix']) namebuilder.push('juniorcomma=true')
      return namebuilder.join(', ')
    }

    if (family && Zotero.Utilities.XRegExp.test(family, this.re.startsWithLowercase)) family = new String(family) // eslint-disable-line no-new-wrappers

    if (family) family = this._enc_creator_part(family)

    let latex = ''
    if (name['dropping-particle']) latex += this._enc_creator_part(this._enc_creators_pad_particle(name['dropping-particle']))
    if (name['non-dropping-particle']) latex += this._enc_creator_part(this._enc_creators_pad_particle(name['non-dropping-particle']))
    if (family) latex += family
    if (name.suffix) latex += `, ${this._enc_creator_part(name.suffix)}`
    if (name.given) latex += `, ${this._enc_creator_part(name.given)}`

    return latex
  }

  private _enc_creators_bibtex(name): string {
    let family: string | String
    if ((name.family.length > 1) && (name.family[0] === '"') && (name.family[name.family.length - 1] === '"')) { // quoted
      family = new String(name.family.slice(1, -1)) // eslint-disable-line no-new-wrappers
    }
    else {
      family = name.family
    }

    // cleanup from old initials detection
    name.given = name.given?.replace(enc_creators_marker.initials, '')
    this.detectInitials(name)
    this.protectInitials(name)

    /*
      TODO: http://chat.stackexchange.com/rooms/34705/discussion-between-retorquere-and-egreg

      My advice is never using the alpha style; it's a relic of the past, when numbering citations was very difficult
      because one didn't know the full citation list when writing a paper. In order to have the bibliography in
      alphabetical order, such tricks were devised. The alternative was listing the citation in order of appearance.
      Your document gains nothing with something like XYZ88 as citation key.

      The “van” problem should be left to the bibliographic style. Some styles consider “van” as part of the name, some
      don't. In any case, you'll have a kludge, mostly unportable. However, if you want van Gogh to be realized as vGo
      in the label, use {\relax van} Gogh or something like this.
    */

    // eslint-disable-next-line @typescript-eslint/restrict-plus-operands
    if (name['non-dropping-particle']) family = new String(this._enc_creators_pad_particle(name['non-dropping-particle']) + family) // eslint-disable-line no-new-wrappers
    if (Zotero.Utilities.XRegExp.test(family, this.re.startsWithLowercase) || Zotero.Utilities.XRegExp.test(family, this.re.hasLowercaseWord)) family = new String(family) // eslint-disable-line no-new-wrappers

    // https://github.com/retorquere/zotero-better-bibtex/issues/978 -- enc_latex can return null
    family = family ? this._enc_creator_part(family) : ''

    // https://github.com/retorquere/zotero-better-bibtex/issues/976#issuecomment-393442419
    if (family[0] !== '{' && name.family.match(/[-\u2014\u2015\u2012\u2013]/)) family = `{${family}}`

    if (name['dropping-particle']) family = `${this._enc_creator_part(this._enc_creators_pad_particle(name['dropping-particle'], true))}${family}`

    if (this.translation.BetterBibTeX && this.translation.preferences.bibtexParticleNoOp && (name['non-dropping-particle'] || name['dropping-particle'])) {
      family = `{\\noopsort{${this._enc_creator_part(name.family.toLowerCase())}}}${family}`
      this.metadata.noopsort = true
    }

    if (name.given) name.given = this._enc_creator_part(name.given)
    if (name.suffix) name.suffix = this._enc_creator_part(name.suffix)

    let latex: string = (family as string)
    if (name.suffix) latex += `, ${name.suffix}`
    if (name.given) latex += `, ${name.given}`

    return latex
  }

  private postscript(_entry, _item, _translator, _zotero, _extra): postscript.Allow {
    return { cache: true, write: true }
  }

  public thesistype(type: string, phdthesis: string, mastersthesis: string, bathesis?: string, candthesis?: string): string {
    return {
      phd: phdthesis,
      dissertation: phdthesis,
      phddissertation: phdthesis,
      doctoraldissertation: phdthesis,

      ma: mastersthesis,
      master: mastersthesis,
      masters: mastersthesis,
      mphil: mastersthesis,

      ba: bathesis,
      bachelor: bathesis,
      bachelors: bathesis,
      undergrad: bathesis,
      undergraduate: bathesis,

      cand: candthesis,
      candidate: candthesis,
      candidates: candthesis,
    }[type?.toLowerCase().replace(/[^a-z]/g, '').replace(/thesis$/, '')]
  }

  private qualityReport(): string {
    // the quality report will access a bunch of fields not to export them but just to see if they were used, and that triggers the cacheDisabler proxy when
    // the 'collections' field is accessed... rendering a lot of items uncacheable
    const $cacheable = this.item.$cacheable
    try {
      if (!this.translation.preferences.qualityReport) return ''

      let report: string[] = this.lint({
        timestamp: `added because JabRef format is set to ${this.translation.preferences.jabrefFormat || '?'}`,
      })

      if (report) {
        if (this.has.pages) {
          const dashes = this.has.pages.bibtex.match(/-+/g)
          // if (dashes && dashes.includes('-')) report.push('? hyphen found in pages field, did you mean to use an en-dash?')
          if (dashes && dashes.includes('---')) report.push('? em-dash found in pages field, did you mean to use an en-dash?')
        }
        if (this.has.journal && this.has.journal.value.indexOf('.') >= 0) report.push(`? Possibly abbreviated journal title ${this.has.journal.value}`)
        if (this.has.journaltitle && this.has.journaltitle.value.indexOf('.') >= 0) report.push(`? Possibly abbreviated journal title ${this.has.journaltitle.value}`)

        if (this.entrytype === 'inproceedings' && this.has.booktitle) {
          if (!this.has.booktitle.value.match(/:|Proceedings|Companion| '/) || this.has.booktitle.value.match(/\.|workshop|conference|symposium/)) {
            report.push('? Unsure about the formatting of the booktitle')
          }
        }

        if (this.has.title && this.translation.preferences.exportTitleCase) {
          const titleCased = titleCase(this.has.title.value) === this.has.title.value
          if (this.has.title.value.match(/\s/)) {
            if (titleCased) report.push('? Title looks like it was stored in title-case in Zotero')
          }
          else {
            if (!titleCased) report.push('? Title looks like it was stored in lower-case in Zotero')
          }
        }
      }
      else {
        report = [`I don't know how to quality-check ${this.entrytype} entries`]
      }

      report = report.concat(this.quality_report)

      let used_values: Array<string | number> = Object.values(this.has) // eslint-disable-line @typescript-eslint/array-type
        .filter(field => typeof field.value === 'string' || typeof field.value === 'number')
        .map(field => `${field.value}`)
        .filter(value => value)
      used_values = used_values.concat(used_values.map(value => this.valueish(value)))

      const ignore_unused_props = [
        'abstractNote',
        'accessDate',
        'autoJournalAbbreviation',
        'citationKey',
        'citekey',
        'collections',
        'date',
        'dateAdded',
        'dateModified',
        'itemID',
        'itemType',
        'itemKey',
        'key',
        'libraryID',
        'relations',
        'rights',
        'uri',
        'version',
      ]
      const unused_props = Object.entries(this.item.extraFields.kv).map(([p, v]) => [ `extra: ${propertyLabel[p.toLowerCase()] || p}`, v ])
        .concat(Object.entries(this.item))
        .map(([p, v]) => [p, v, this.valueish(v) ])
        .filter(([p, v, vi]) => !ignore_unused_props.includes(p) && !used_values.includes(v) && (vi && !used_values.includes(vi)))
        .sort(property_sort)

      for (const [prop, value, valueish] of unused_props) {
        if (prop === 'language' && this.has.langid) continue
        if (prop === 'libraryCatalog' && valueish.includes('arxiv') && this.item.arXiv) continue
        report.push(`? unused ${propertyLabel[prop.toLowerCase()] || prop} ("${value}")`)
      }

      if (!report.length) return ''

      report.unshift(`== ${this.translation.BetterBibTeX ? 'BibTeX' : 'BibLateX'} quality report for ${this.item.citationKey}:`)
      return report.map(line => `% ${line}\n`).join('')
    }
    finally {
      // restore cacheable state
      this.item.$cacheable = $cacheable
    }
  }

  private unique_chars(str) {
    let uniq = ''
    for (const c of str) {
      if (uniq.indexOf(c) < 0) uniq += c
    }
    return uniq
  }

  public toEnglishOrdinal(n: number | string): string {
    const sortaNum = typeof n === 'number' ? `${n}` : (n || '').replace(/(st|nd|th)$/, '')
    if (sortaNum.match(/^[0-9]{1,2}$/)) {
      return toWordsOrdinal(sortaNum).replace(/^\w/, (c: string) => c.toUpperCase())
    }
    else if (sortaNum.match(/^[0-9]+$/)) {
      return toOrdinal(sortaNum).replace(/^\w/, (c: string) => c.toUpperCase())
    }
    else {
      return typeof n === 'string' ? n : ''
    }
  }
}

//  @polyglossia = [
//    'albanian'
//    'amharic'
//    'arabic'
//    'armenian'
//    'asturian'
//    'bahasai'
//    'bahasam'
//    'basque'
//    'bengali'
//    'brazilian'
//    'brazil'
//    'breton'
//    'bulgarian'
//    'catalan'
//    'coptic'
//    'croatian'
//    'czech'
//    'danish'
//    'divehi'
//    'dutch'
//    'english'
//    'british'
//    'ukenglish'
//    'esperanto'
//    'estonian'
//    'farsi'
//    'finnish'
//    'french'
//    'friulan'
//    'galician'
//    'german'
//    'austrian'
//    'naustrian'
//    'greek'
//    'hebrew'
//    'hindi'
//    'icelandic'
//    'interlingua'
//    'irish'
//    'italian'
//    'kannada'
//    'lao'
//    'latin'
//    'latvian'
//    'lithuanian'
//    'lsorbian'
//    'magyar'
//    'malayalam'
//    'marathi'
//    'nko'
//    'norsk'
//    'nynorsk'
//    'occitan'
//    'piedmontese'
//    'polish'
//    'portuges'
//    'romanian'
//    'romansh'
//    'russian'
//    'samin'
//    'sanskrit'
//    'scottish'
//    'serbian'
//    'slovak'
//    'slovenian'
//    'spanish'
//    'swedish'
//    'syriac'
//    'tamil'
//    'telugu'
//    'thai'
//    'tibetan'
//    'turkish'
//    'turkmen'
//    'ukrainian'
//    'urdu'
//    'usorbian'
//    'vietnamese'
//    'welsh'
//  ]
